JavaScript 学习笔记:原型与继承

原型

每个 JavaScript 中的对象都有一个特殊的内部隐藏属性 [[prototype]],它要么指向 null,要么指向另一个对象,我们称之为原型。当我们从一个对象上读取某个属性,如果在对象本身上没有找到,那么 JavaScript 引擎会尝试从它的原型中去寻找。

原型属性 [[prototype]] 虽然是内部隐藏的属性,但是有一些方法可以获取到它。

__proto__

其中之一是 __proto__,但是 __proto__ 并不完全等同于原型,只是由于历史原因,各个浏览器包括 NodeJS 都部署了这一属性,实际开发中应该使用 ES 规范中的更现代的设置原型的方法,后面会提到。考虑如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 代码片段 1
const animal = {
eats: true
};

const rabbit = {
jumps: true
};

rabbit.__proto__ = animal;

console.log(rabbit.jumps); // true
console.log(rabbit.eats); // true

rabbit 对象本身没有 eats 属性,但是因为让它的原型指向了 animal 对象,这时我们可以说 animalrabbit 的原型。所以引擎会在 rabbit 没有某个属性时从其原型 animal 上获取。

因此,我们可以将许多有用,但是更加通用的属性放到抽象程度更高的对象中,显然,在这里是 animal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 代码片段 2
const animal = {
eats: true,
walk() {
alert("Animal walk");
}
};

const rabbit = {
jumps: true,
__proto__: animal
};

// walk is taken from the prototype
rabbit.walk(); // Animal walk

现在 rabbit 通过原型,继承了 animal 的共同属性 eatswalk。原型链 prototype chain 还可以更长:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 代码片段 3
const animal = {
eats: true,
walk() {
alert("Animal walk");
}
};

const rabbit = {
jumps: true,
__proto__: animal
};

const longEar = {
earLength: 10,
__proto__: rabbit
};

// walk is taken from the prototype chain
longEar.walk(); // Animal walk
alert(longEar.jumps); // true (from rabbit)

现在,按照抽象程度由低到高形成了一条原型链:longEar -> rabbit -> animal

原型链的限制

对于原型链来说主要由下面 3 条限制:

  1. 原型链不能构成环,否则引擎将会抛出错误,在上面的例子中,即不能再将 animal 的原型指向 longEar
  2. __proto__ 的值类型要么是 null,要么是 Object,即另一个对象。所有基本类型的值会被忽略。
  3. 一个对象只能有一个原型,即不能继承两个父级。

this 的值

当对象调用原型的方法时,this 的值是指代原型还是对象本身呢?下面的代码清晰的说明了这一问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const user = {
name: "John",
surname: "Smith",
get fullname() {
return `${this.name} ${this.surname}`;
},
set fullname(value) {
[this.name, this.surname] = value.split(" ");
}
};

const admin = {
name: "Norah",
surname: "Jones",
isAdmin: true,
__proto__: user
};

console.log(admin.fullname); // Norah Jones
admin.fullname = "Michael Jackson";
console.log(admin.fullname); // Michael Jackson

不管某个方法是在对象本身上,还是在其原型上,this 永远指向 . 运算符前面的对象。

F.prototype

我们知道,对象除了可以使用字面量创建,还可以使用 new F() 形式的构造函数来创建。请注意,构造函数本质上依然是函数,JavaScript 中的每个函数都具有一个特殊的 prototype 属性。默认情况下,它是一个仅包含 constructor 属性的对象(不包括每个对象的隐藏属性 [[prototype]],因为函数也是对象),其中 constructor 指向函数本身:

1
2
3
4
const F = function() {};

console.log(F.prototype); // { constructor: f }
console.log(F.prototype.constructor === F); // true

如果我们让 F.prototype 指向一个对象,那么在使用 new 操作符调用构造函数 F 时,新创建的实例的原型 [[prototype]] 都将被指向这个对象。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const animal = {
eats: true
};

const Rabbit = function(name) {
this.name = name;
};

Rabbit.prototype = animal; // 设置所有 Rabbit 创建的实例的原型为 animal
Rabbit.prototype.construtor = Rabbit; // 修正 Rabbit 原型的 constructor 的指向 (*)

const rabbit = new Rabbit("white rabbit");

console.log(rabbit.eats); // true
console.log(rabbit.__proto__ === animal); // true
console.log(rabbit.constructor === Rabbit); // true

上面的代码中,另一个需要注意的问题是 F.prototype.constructor 的值。改变构造函数 F.prototype 的默认指向,如果接下来(之前创建的实例不会受到影响)使用到其新创建的实例的 construtor 属性,会导致意想不到的错误。拿上面的例子来说,如果没有 (*) 这一行代码,rabbit.constructor 的值将会指向 animal,显然这在人意料之外,也不合理。所以,普遍的共识是:不要依赖 constructor 这一属性,因为它能被任意修改。

有时候,我们需要使用某个实例的构造函数来创建一个实例,但是却不知道它的构造函数。这时可以这样做,但是需要小心:

1
2
3
4
5
6
7
const User = function(name) {
this.name = name;
};

const user = new User("Bill");
const user2 = new user.constructor("Sunny");
alert(user2.name); // Sunny

上面的代码可以如正常运行,因为构造函数 Userprototype 的指向是默认的,指向其自身。假如修改一下代码,改变其指向:

1
2
3
4
5
6
7
8
const User = function(name) {
this.name = name;
};

User.prototype = {};
const user = new User("Bill");
const user2 = new user.constructor("Sunny"); // (*)
alert(user2.name); // undefined

我们来看看 (*) 这一行发生了什么:

  1. 首先引擎会在 user 对象上查找 constructor 属性,没有找到。
  2. 接着引擎会沿着原型链查找,user 的原型是 User.prototype,它是一个空对象 {},没有找到。
  3. 空对象 {} 的原型是 Object.prototype,而 Object.prototype.constructor === Object。所以实际执行的是 new Object('Sunny'),而内置的对象构造函数会忽略所有参数,所以得到如上结果。

Object.prototype

让我们来看下面的代码:

1
2
const obj = {};
alert(obj); // [object Object]

为什么会有以上的输出?我们定义的对象是空的,是哪里来的代码生成了 [object Object] 这样的字符串信息?答案是内置的 toString 方法。通过字面量创建对象等同于使用对象构造函数 new Object()。而 Object 构造函数就像所有函数一样,也有一个 prototype 属性,它指向了一个很大的对象,上面部署了 constructor, toString, valueOf 等一系列所有对象通用的方法和属性。

以下代码可以检查上面所说:

1
2
3
4
const obj = {};

alert(obj.__proto__ === Object.prototype); // true
// obj.toString === obj.__proto__.toString == Object.prototype.toString

另外,Object.prototype 这个内置的原型的 [[prototype]] 指向了谁呢?答案是 null:

1
alert(Object.prototype.__proto__); // null

其他抽象程度稍低的一些内置对象,比如 FunctionArrayDate 等,也部署了一些方法在其原型上。这样每一个函数实例,数组实例或者日期实例,都可以使用一些内置于其原型上的方法了。这样设计的目的非常利于节省内存。下面这张图局部地说明了这种关系。

我们可以手动地验证一下:

1
2
3
4
5
6
7
8
9
10
const arr = [1, 2, 3];

alert(arr.__proto__ === Array.prototype); // true
alert(arr.__proto__.__proto__ === Object.prototype); // true
alert(arr.__proto__.__proto__.__proto__); // null

const f = function() {};

alert(f.__proto__ === Function.prototype); // true
alert(f.__proto__.__proto__ === Object.prototype); // true

基本类型的原型

对于 String, Number, Boolean 这 3 种基本类型而言,当访问它们的属性时,会使用内置的构造函数(比如 Number())创建一个临时的封装对象,然后访问这个临时封装对象原型上的属性或者方法,访问完成后临时对象就消失了,这一过程对我们来说都是不可见的。

对于 nullundefined 而言,不会有自动创建临时封装对象这一过程,因为它们没有可用的属性或者方法,也就没有内置的原型。

设置原型的现代方法

我们应该只考虑以下 3 种现代的设置原型的方法:

让我们用更标准、现代的方法改写前面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const animal = {
eats: true
};

const rabbit = Object.create(animal, {
jumps: {
value: true
}
});

alert(rabbit.eats); // true
alert(Object.getPrototypeOf(rabbit) === animal); // true

Object.setPrototypeof(rabbit, {}); // 改变 rabbit 的原型,使其原型为一个空对象

使用原型方法进行浅拷贝

1
2
3
const origin = {...};

const clone = Object.create(Object.getPrototypeOf(origin), Object.getOwnPropertyDescriptors(origin));

上面的代码,以源对象的原型为原型,加上源对象的所有属性描述符选项,复制了所有源对象的属性,不管是可枚举属性还是不可枚举属性,数据属性还是访问属性,也包括了 Symbol 属性;同时有着相同的原型。

让我们看一个详细的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 源对象
const origin = Object.create(null, {
name: {
value: "Sheldon",
writable: true,
enumerable: true,
configurable: true
},
surname: {
value: "Cooper",
writable: true,
enumerable: true,
configurable: true
},
fullname: {
get() {
return `${this.name} ${this.surname}`;
},
set(value) {
[this.name, this.surname] = value.split(" ");
}
},
[Symbol("id")]: {
value: 9527,
writable: false,
configurable: true,
enumerable: true
},
toString: {
value: function() {
return "custom toString!";
},
writable: true,
enumerable: false,
configurable: true
}
});

// 浅拷贝
const clone = Object.create(
Object.getPrototypeOf(origin),
Object.getOwnPropertyDescriptors(origin)
);

// 可枚举属性和 Symbol 属性复制成功
console.log(clone); // { name: 'Sheldon', surname: 'Cooper', [Symbol(id)]: 9527 }
// 不可枚举属性复制成功
console.log(clone.toString()); // custom toString!
// 访问属性复制成功
console.log(clone.fullname); // Sheldon Cooper
// 原型复制成功
console.log(Object.getPrototypeOf(clone) === null); // true

上面代码的源对象是一个出于演示目的而定制的,包含了可枚举属性和不可枚举属性,数据属性和访问属性,以及 Symbol 属性,从最后的打印结果可以看到,拷贝对象全部成功地复制了过来。

不要轻易在初始化更改原型设置

以上面的代码为例,如果我们在一开始让 rabbitanimal 为原型,接下来不要轻易更改 rabbit 的原型,因为引擎会对已有的原型继承做很多属性读取之类的优化,更改原型会破坏这些已有的优化,导致运行速度变慢。

纯字典对象

假设我们想要实现一个纯字典对象,即包含一切合法字符串为键的键值对,使用通常的字面量对象会有一个问题:有一个特殊的字符串 __proto__ 无法如预期般奏效。

1
2
3
4
5
6
const dictionary = {
__proto__: "some value"
};

alert(dictionary.__proto__); // [object Object]
alert(dictionary.__proto__ === Object.prototype); // true

上面的属性读取无法得到预期的 some value 结果,因为通常字面量创建的对象,默认原型是 Object.prototype ,而因为历史原因 __proto__ 这一特殊属性被当做访问原型的途径。如果将其赋值为基本类型值,赋值操作将会被忽略。

如何解决上面的问题?有两种方式:

  • 使用 Map,通常这是更加推荐的做法。
  • 使用 Object.create(null) 从所有原型的顶端 null 继承,这样创建的对象是一个真正的纯对象,不会包含一般对象内置的 toStringvalueOf__proto__ 等属性。

属性遍历方法比较

  • Object.keys(obj) / Object.values(obj) / Object.entries(obj) – 返回一个包含对象自身的可枚举字符串属性的键 / 值 / 键值对的数组。不包含继承属性,不包含 Symbol 属性。
  • Object.getOwnPropertySymbols(obj) – 返回一个包含自身所有 Symbol 属性的数组。
  • Object.getOwnPropertyNames(obj) – 返回一个包含自身所有字符串属性的数组。
  • Reflect.ownKeys(obj) – 返回一个包含自身所有属性的数组。
  • obj.hasOwnProperty(key) - 如果自身(非继承)包含该属性,返回真。
  • for...in 循环 - 遍历所有可枚举的字符串属性,包括自身属性和继承属性。不包括不可枚举属性,不包括 Symbol 属性。

基于原型实现继承

虽然 ES6 引入了 class 关键字,但那只是一种语法糖。JavaScript 中继承的实现本质上是基于原型的。原型继承的实现参考以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function Animal(name) {
this.name = name;
}

Animal.prototype.eat = function() {
console.log(`${this.name} eats`);
};

Animal.prototype.walk = function() {
console.log(`${this.name} walks`);
};

function Rabbit(name) {
this.name = name;
}

Rabbit.prototype.walk = function() {
console.log(`${this.name} bounces`);
};

// 让 Rabbit 继承 Animal
Object.setPrototypeOf(Rabbit.prototype, Animal.prototype);
// 或者这样写: Rabbit.prototype = Object.create(Animal.prototype);
// 不推荐写法: Rabbit.prototype.__proto__ = Object.prototype

const rabbit = new Rabbit("white rabbit");
rabbit.walk(); // white rabbit bounces
rabbit.eat(); // white rabbit eats